Skip to content

src: keep the first snapshot blob alive for later isolates - #65779

Open
codebytere wants to merge 2 commits into
nodejs:mainfrom
codebytere:fix/embedder-snapshot-first-params-uaf
Open

src: keep the first snapshot blob alive for later isolates#65779
codebytere wants to merge 2 commits into
nodejs:mainfrom
codebytere:fix/embedder-snapshot-first-params-uaf

Conversation

@codebytere

@codebytere codebytere commented Sep 4, 2026

Copy link
Copy Markdown
Member

An embedder that creates a CommonEnvironmentSetup from an EmbedderSnapshotData, tears it down, releases the snapshot data and later creates a second setup hands V8 freed memory: the second isolate is deserialized from the first, already-freed blob. It happens to work with glibc because the pages are usually still intact; ASAN reports a heap-use-after-free in v8::internal::Snapshot::Initialize.

V8 shares the read-only heap between isolates, so NewIsolate() creates every isolate from the snapshot blob the first isolate in the process used. It did that by keeping a pointer to the first caller's CreateParams in a function-local static (the comment next to it already said "this isn't really memory-safe"), and nothing kept the blob those params point at alive.

NewIsolate() now records the first blob and its external references under a mutex instead of the caller's CreateParams, and ~SnapshotData() leaves that one blob allocated, since its owner can go away before the last isolate is created. Nothing is copied, and node itself is unaffected because its snapshot already lives until TearDownOncePerProcess(). node.h now says that snapshot_data has to outlive the setup and that every setup in a process has to use the same snapshot.

A second commit fixes a related race in the same first-isolate setup: two threads creating their first isolate at the same time (two setups on their own threads, or an embedder's setup racing a Worker) could both append the terminating entry to the external reference list handed to V8, or one could read it while the other reallocated it, because SnapshotBuilder::CollectExternalReferences() finalized the list on every call without locking. The finalized list now lives in a function-local static so that happens exactly once. TSAN reports the race for any two concurrent setups.

Tests:

  • test/embedding/test-embedding-snapshot-twice.js runs a snapshot twice in one embedtest process (new --embedder-run-twice switch), freeing the EmbedderSnapshotData in between; heap-use-after-free under ASAN before the change, clean after.
  • EnvironmentTest.CollectExternalReferencesFromSeveralThreads calls CollectExternalReferences() from eight threads and checks they all see the same finalized list; TSAN reports the race before the second commit.
  • The rest of test/embedding passes.

Refs: #45885
Refs: #32984


Disclosure: the code, tests and this description were written by Claude Code, directed and reviewed by @codebytere.

`NewIsolate()` creates every isolate from the snapshot blob the first
isolate in the process used, because V8 shares the read-only heap
between isolates, and did so by keeping a pointer to the first
`CreateParams`. When that blob came from an `EmbedderSnapshotData` the
embedder had since released, e.g. a second
`CommonEnvironmentSetup::CreateFromSnapshot()` after the first setup and
its snapshot were destroyed, V8 deserialized freed memory.

Record the first blob and external references under a mutex instead of
copying the caller's `CreateParams`, and make `~SnapshotData()` leave
that one blob allocated, since its owner can go away before the last
isolate is created. Nothing is copied and `node` itself is unaffected.
embedtest grows an `--embedder-run-twice` switch so the sequence can be
tested.

Refs: nodejs#45885
Signed-off-by: Shelley Vohr <[email protected]>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. labels Sep 4, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.21%. Comparing base (2befec5) to head (0cda701).
⚠️ Report is 110 commits behind head on main.

Files with missing lines Patch % Lines
src/node_snapshotable.cc 80.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65779      +/-   ##
==========================================
+ Coverage   89.99%   90.21%   +0.21%     
==========================================
  Files         757      771      +14     
  Lines      257739   264638    +6899     
  Branches    48881    50233    +1352     
==========================================
+ Hits       231961   238730    +6769     
- Misses      16861    16914      +53     
- Partials     8917     8994      +77     
Files with missing lines Coverage Δ
src/api/environment.cc 79.16% <100.00%> (+0.60%) ⬆️
src/node.h 91.66% <ø> (ø)
src/node_internals.h 80.35% <ø> (ø)
src/node_snapshotable.cc 73.61% <80.00%> (+0.08%) ⬆️

... and 127 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Two threads creating their first isolate at the same time (two
`CommonEnvironmentSetup`s on their own threads, or an embedder's setup
racing a Worker) could corrupt or misread the external reference list
handed to V8: `SnapshotBuilder::CollectExternalReferences()` creates its
registry in a thread-safe function static, but then calls
`external_references()` on every call, and that method appends the
terminating nullptr and flips `is_finalized_` the first time through
without any locking, so both threads can append, or one can read the
vector while the other reallocates it. TSAN reports it for any two
concurrent setups.

Keep the finalized list in a second function static so finalization
runs exactly once, under that static's initialization guard.

Refs: nodejs#32984
Signed-off-by: Shelley Vohr <[email protected]>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants